<!DOCTYPE html>
<html>

<head>
  <meta charset="utf-8">
  <title>Spark • Editor</title>
  <style>
    body {
      margin: 0;
    }
    #canvas {
      position: absolute;
      top: 0;
      left: 0;
      width: 100%;
      height: 100%;
      outline: none; /* Remove default focus outline */
      touch-action: none;
    }
    #main-gui {
      position: absolute;
      top: 5px;
      right: 5px;
    }
    #second-gui {
      position: absolute;
      top: 5px;
      left: 5px;
    }
    #progress-bar {
      position: fixed;
      top: 0;
      left: 0;
      width: 100%;
      height: 4px;
      background-color: rgba(0, 0, 0, 0.1);
      z-index: 1000;
      display: none;
    }
    #progress-fill {
      height: 100%;
      background: linear-gradient(90deg, #4CAF50, #45a049);
      width: 0%;
      transition: width 0.3s ease;
    }
  </style>
</head>

<body>
  <div id="progress-bar">
    <div id="progress-fill"></div>
  </div>
  <script src="/examples/js/vendor/stats.js/build/stats.min.js"></script>
  <script type="importmap">
    {
      "imports": {
        "three": "/examples/js/vendor/three/build/three.module.js",
        "three/addons/": "/examples/js/vendor/three/examples/jsm/",
        "@sparkjsdev/spark": "/dist/spark.module.js",
        "lil-gui": "/examples/js/vendor/lil-gui/dist/lil-gui.esm.js"
      }
    }
  </script>
  <canvas id="canvas" tabindex="0"></canvas>
  <div id="main-gui"></div>
  <div id="second-gui"></div>
  <input id="file-input" type="file" accept=".ply,.spz,.splat,.ksplat,.zip" multiple="true" style="display: none;" />
  <script type="module">
    import * as THREE from "three";
    import { OrbitControls } from "three/addons/controls/OrbitControls.js";
    import { GUI } from "lil-gui";
    import { constructGrid, SparkControls, SparkRenderer, SplatMesh, textSplats, dyno, transcodeSpz, isMobile, isPcSogs, LN_SCALE_MIN, LN_SCALE_MAX } from "@sparkjsdev/spark";
    import { getAssetFileURL } from "/examples/js/get-asset-url.js";

    const scene = new THREE.Scene();
    const camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.01, 1000);
    camera.position.set(0, 0, 1);

    const canvas = document.getElementById("canvas");
    const renderer = new THREE.WebGLRenderer({ canvas });
    const spark = new SparkRenderer({ renderer });
    scene.add(spark);

    function handleResize() {
      const width = canvas.clientWidth;
      const height = canvas.clientHeight;
      renderer.setSize(width, height, false);
      camera.aspect = width / height;
      camera.updateProjectionMatrix();
    }

    handleResize();
    window.addEventListener("resize", handleResize);

    const frame = new THREE.Group();
    frame.quaternion.set(1, 0, 0, 0);
    scene.add(frame);

    // Keep track of original file bytes for each loaded splat mesh
    const inputs = [];

    const grid = new SplatMesh({
      constructSplats: (splats) => constructGrid({
        splats,
        extents: new THREE.Box3(new THREE.Vector3(-10, -10, -10), new THREE.Vector3(10, 10, 10)),
      }),
    });
    grid.opacity = 0;
    scene.add(grid);

    const stats = new Stats();
    document.body.appendChild(stats.dom);
    stats.dom.style.display = "none";

    const gui = new GUI({
      title: "Settings",
      container: document.getElementById("main-gui")
    });
    const secondGui = new GUI({
      title: "Splats",
      container: document.getElementById("second-gui")
    }).close();

    const controls = new SparkControls({ canvas });

    // Setup mouse controls to orbit the camera around
    const orbitControls = new OrbitControls(camera, renderer.domElement);
    orbitControls.enabled = false;
    orbitControls.target.set(0, 0, 0);
    orbitControls.minDistance = 0.1;
    orbitControls.maxDistance = 10;

    const fileInput = document.querySelector("#file-input");
    fileInput.onchange = (event) => {
      loadFiles([...event.target.files]);
    };

    const guiOptions = {
      highDevicePixel: !isMobile(),
      stats: false,
      resetOnLoad: true,
      loadOffset: 0,
      openCv: true,
      autoRotate: true,
      orbit: false,
      reversePointerDir: false,
      reversePointerSlide: false,
      backgroundColor: "#000000",
      viewBoundingBox: false,
      openFiles: () => {
        fileInput.click();
      },
      loadFromText: "",
      loadFromTextAction: () => {
        if (guiOptions.loadFromText.trim()) {
          const urls = parseURLsFromText(guiOptions.loadFromText);
          if (urls.length > 0) {
            loadFiles(urls);
            guiOptions.loadFromText = ""; // Clear after loading
          } else {
            alert("No valid URLs found in text. URLs must start with http:// or https:// and end with .ply, .spz, .splat, .ksplat, .json, or .zip");
          }
        }
      },
      resetPose: () => {
        camera.position.set(0, 0, 1);
        camera.quaternion.set(0, 0, 0, 1);
        camera.fov = 75;
        resetFrameQuaternion();
        camera.updateProjectionMatrix();
      },
    };

    function resetFrameQuaternion() {
      if (guiOptions.openCv) {
        frame.quaternion.set(1, 0, 0, 0);
      } else {
        frame.quaternion.set(0, 0, 0, 1);
      }
    }

    // Parse URL query parameters for URLs to load
    function parseURLsFromQuery() {
      const urlParams = new URLSearchParams(window.location.search);
      const urls = [];

      // Get all "url" parameters
      for (const [key, value] of urlParams.entries()) {
        if (key === "url") {
          urls.push(value);
        }
      }

      return urls;
    }

    // Parse and apply camera parameters from URL
    function applyCameraFromQuery() {
      const urlParams = new URLSearchParams(window.location.search);

      // Position parameters
      const x = urlParams.get('x');
      const y = urlParams.get('y');
      const z = urlParams.get('z');

      if (x !== null || y !== null || z !== null) {
        camera.position.set(
          x !== null ? parseFloat(x) : camera.position.x,
          y !== null ? parseFloat(y) : camera.position.y,
          z !== null ? parseFloat(z) : camera.position.z
        );
      }

      // Quaternion parameters
      const qx = urlParams.get('qx');
      const qy = urlParams.get('qy');
      const qz = urlParams.get('qz');
      const qw = urlParams.get('qw');

      if (qx !== null || qy !== null || qz !== null || qw !== null) {
        camera.quaternion.set(
          qx !== null ? parseFloat(qx) : camera.quaternion.x,
          qy !== null ? parseFloat(qy) : camera.quaternion.y,
          qz !== null ? parseFloat(qz) : camera.quaternion.z,
          qw !== null ? parseFloat(qw) : camera.quaternion.w
        ).normalize();
      }
    }

    // Apply camera parameters from URL
    guiOptions.resetPose();
    applyCameraFromQuery();

    const cameraFolder = gui.addFolder("Camera");
    const cameraPose = cameraFolder.addFolder("Camera Pose");
    cameraPose.add(camera.position, "x", -10, 10, 0.01).name("X").listen();
    cameraPose.add(camera.position, "y", -10, 10, 0.01).name("Y").listen();
    cameraPose.add(camera.position, "z", -10, 10, 0.01).name("Z").listen();
    const rotX = cameraPose.add(camera.rotation, "x", -Math.PI, Math.PI, 0.01).name("RotateX").listen();
    const rotY = cameraPose.add(camera.rotation, "y", -Math.PI, Math.PI, 0.01).name("RotateY").listen();
    const rotZ = cameraPose.add(camera.rotation, "z", -Math.PI, Math.PI, 0.01).name("RotateZ").listen();
    cameraPose.add(camera, "fov", 1, 179, 1).name("Fov Y degrees").listen().onChange((value) => {
      camera.updateProjectionMatrix();
    });
    cameraPose.close();

    function touch() {
      spark.needsUpdate = true;
    }

    // Progress bar functions
    const progressBar = document.getElementById('progress-bar');
    const progressFill = document.getElementById('progress-fill');

    function showProgress() {
      progressBar.style.display = "block";
      progressFill.style.width = "0%";
    }

    function updateProgress(progress) {
      progressFill.style.width = `${Math.min(100, Math.max(0, progress * 100))}%`;
    }

    function hideProgress() {
      progressBar.style.display = "none";
    }

    // Calculate progress when total size is unknown
    function calculateUnknownProgress(loadedBytes) {
      const midpointMB = 10 * 1024 * 1024; // 10 MB in bytes
      return loadedBytes / (loadedBytes + midpointMB);
    }

    async function fetchWithProgress(url) {
      try {
        const response = await fetch(url, {
          mode: "cors",
          cache: "default",
        });

        if (!response.ok) {
          throw new Error(`HTTP error! status: ${response.status}`);
        }

        const contentLength = response.headers.get('content-length');
        const total = contentLength ? parseInt(contentLength, 10) : null;

        const reader = response.body.getReader();
        const chunks = [];
        let loadedBytes = 0;

        while (true) {
          const { done, value } = await reader.read();

          if (done) break;

          chunks.push(value);
          loadedBytes += value.length;

          // Calculate progress
          let progress;
          if (total) {
            progress = loadedBytes / total;
          } else {
            progress = calculateUnknownProgress(loadedBytes);
          }

          updateProgress(progress);
        }

        // Combine all chunks into a single Uint8Array
        const allChunks = new Uint8Array(loadedBytes);
        let offset = 0;
        for (const chunk of chunks) {
          allChunks.set(chunk, offset);
          offset += chunk.length;
        }

        return allChunks.buffer;
      } catch (error) {
        // Hide progress bar on error
        hideProgress();

        // Detect CORS issues
        if (error instanceof TypeError && (
          error.message.includes('Failed to fetch') ||
          error.message.includes('Network request failed') ||
          error.message.includes('CORS') ||
          error.message.includes('cross-origin')
        )) {
          alert(`CORS Error: Cannot load file from ${url}\n\nThe server doesn't allow cross-origin requests. The file needs to be:\n• Served from the same domain, or\n• The server must include CORS headers like:\n  Access-Control-Allow-Origin: *`);
        } else if (error.message.includes('HTTP error! status:')) {
          const status = error.message.match(/status: (\d+)/)?.[1];
          alert(`HTTP Error ${status}: Cannot load file from ${url}\n\n${error.message}`);
        } else {
          alert(`Error loading file from ${url}:\n\n${error.message}`);
        }

        throw error; // Re-throw so loadFiles can handle it
      }
    }

    async function loadFiles(splatFiles) {
      if (guiOptions.resetOnLoad) {
        const toRemove = frame.children.filter((child) => child instanceof SplatMesh || child instanceof THREE.Box3Helper);
        for (const child of toRemove) {
          frame.remove(child);
        }
        // Clear original file bytes when resetting
        inputs.length = 0;
        splatsFolder.foldersRecursive().forEach((child) => child.destroy());
      }

      guiOptions.openCv = true;
      guiOptions.autoRotate = false;
      resetFrameQuaternion();
      writeOptions.filename = "";

      // Show progress bar if loading from URLs
      const hasUrls = splatFiles.some(file => typeof file === "string");
      if (hasUrls) {
        showProgress();
      }

      let index = 0;
      for (const splatFile of splatFiles) {
        try {
          let fileBytes;
          let fileName;
          let url = null;

          // Check if splatFile is a URL string or a File object
          if (typeof splatFile === "string") {
            // It's a URL, fetch it
            console.log(`Fetching ${splatFile}...`);
            fileBytes = new Uint8Array(await fetchWithProgress(splatFile));
            // Extract filename from URL
            fileName = splatFile.split("/").pop().split("?")[0] || "downloaded-file";

            if (isPcSogs(fileBytes)) {
              url = splatFile;
            }
          } else {
            // It's a File object
            fileBytes = new Uint8Array(await splatFile.arrayBuffer());
            fileName = splatFile.name;
          }

          if (writeOptions.filename === "") {
            writeOptions.filename = fileName.split(".")[0];
          }

          const init = url ? { url } : { fileBytes: fileBytes.slice(), fileName };
          init.splatEncoding = { ...splatEncoding };
          const splatMesh = new SplatMesh(init);
          const translate = guiOptions.loadOffset * index
          splatMesh.position.set(translate, 0.5 * translate, 0.1 * translate);
          splatMesh.enableWorldToView = true;
          splatMesh.worldModifier = makeWorldModifier(splatMesh);
          await splatMesh.initialized;

          if (!url) {
            // PC SOGS transcode not supported yet
            inputs.push({ fileBytes, pathOrUrl: fileName, object: splatMesh });
          }
          frame.add(splatMesh);
          console.log(`Loaded ${fileName} with ${splatMesh.numSplats} splats`);
          addBoundingBoxHelper(splatMesh);

          const splatFolder = splatsFolder.addFolder(fileName).close();
          splatFolder.add(splatMesh, "opacity", 0, 1, 0.01).name("Opacity").listen();
          splatFolder.add(splatMesh.position, "x", -10, 10, 0.01).name("X").listen();
          splatFolder.add(splatMesh.position, "y", -10, 10, 0.01).name("Y").listen();
          splatFolder.add(splatMesh.position, "z", -10, 10, 0.01).name("Z").listen();
          splatFolder.add(splatMesh.scale, "x", 0.01, 4, 0.01).name("Scale").listen().onChange((value) => {
            splatMesh.scale.setScalar(value);
          });
          splatFolder.add(splatMesh.rotation, "x", -Math.PI, Math.PI, 0.01).name("RotateX").listen();
          splatFolder.add(splatMesh.rotation, "y", -Math.PI, Math.PI, 0.01).name("RotateY").listen();
          splatFolder.add(splatMesh.rotation, "z", -Math.PI, Math.PI, 0.01).name("RotateZ").listen();
          splatFolder.add(splatMesh, "maxSh", 0, 3, 1).name("Max SH").listen().onChange(() => {
            splatMesh.updateGenerator();
          });
        } catch (error) {
          console.error("Error loading splat file:", error);
        }
        index += 1;
      }

      // Hide progress bar when done
      if (hasUrls) {
        hideProgress();
      }

      // Restore focus to canvas for keyboard controls
      canvas.focus();
    }

    async function addBoundingBoxHelper(splatMesh) {
      await splatMesh.initialized;
      const box = splatMesh.getBoundingBox();
      const boxHelper = new THREE.Box3Helper(box, 0x00ff00);
      boxHelper.visible = guiOptions.viewBoundingBox;
      frame.add(boxHelper);
    }

    secondGui.add(guiOptions, "resetOnLoad").name("Reset on load");
    secondGui.add(guiOptions, "loadOffset", -2, 2, 0.01).name("Loading offset");
    secondGui.add(guiOptions, "openFiles").name("Select Files");
    secondGui.add(guiOptions, "loadFromText").name("Paste URL(s) here");
    secondGui.add(guiOptions, "loadFromTextAction").name("Load from URL(s)");

    cameraFolder.add(guiOptions, "resetPose").name("Reset pose");
    cameraFolder.add(guiOptions, "openCv").name("OpenCV coordinates")
      .listen()
      .onChange(resetFrameQuaternion);
    cameraFolder.add(guiOptions, "autoRotate").name("Auto rotate")
      .listen()
      .onChange((value) => {
        if (value) {
          frame.rotation.y = 0;
        }
      });
    cameraFolder.add(guiOptions, "orbit").name("Orbit controls")
      .listen()
      .onChange((value) => {
        orbitControls.enabled = value;
        canvas.focus();
        rotX.enable(!value);
        rotY.enable(!value);
        rotZ.enable(!value);
      });
      // .setValue(false);
    cameraFolder.add(guiOptions, "reversePointerDir").name("Reverse ptr direction")
    .onChange((value) => {
      controls.pointerControls.reverseRotate = value;
      controls.pointerControls.reverseScroll = value;
    });
    cameraFolder.add(guiOptions, "reversePointerSlide").name("Reverse ptr slide")
      .onChange((value) => {
        controls.pointerControls.reverseSlide = value;
        controls.pointerControls.reverseSwipe = value;
      });

    function setHighDpi(value) {
      renderer.setPixelRatio(value ? window.devicePixelRatio : 1);
      const width = canvas.clientWidth;
      const height = canvas.clientHeight;
      renderer.setSize(width, height, false);
      console.log("Render size", canvas.width, canvas.height);
    }
    setHighDpi(guiOptions.highDevicePixel);

    gui.add(guiOptions, "highDevicePixel").name("High DPI").onChange((value) => {
      setHighDpi(value);
    });
    gui.add(guiOptions, "stats").name("Show frame stats").onChange((value) => {
      stats.dom.style.display = value ? "block" : "none";
    });
    gui.add(spark.defaultView, "sortRadial").name("Radial sort").listen();
    gui.add(grid, "opacity", 0, 1, 0.01).name("Grid opacity").listen();
    gui.add({
      logFocalDistance: 0.0,
    }, "logFocalDistance", -2, 2, 0.01).name("Ln(Focal distance)").onChange((value) => {
      spark.focalDistance = Math.exp(value);
    });
    gui.add(spark, "apertureAngle", 0, 0.01 * Math.PI, 0.001).name("Aperture angle").listen();
    scene.background = new THREE.Color(guiOptions.backgroundColor);
    gui.addColor(guiOptions, "backgroundColor").name("Background color").onChange((value) => {
      scene.background.set(value);
    });

    const debugFolder = gui.addFolder("Debug").close();
    const normalColor = dyno.dynoBool(false);
    debugFolder.add(normalColor, "value").name("Normal color").onChange(() => updateFrameSplats());
    debugFolder.add(guiOptions, "viewBoundingBox").name("View bounding boxes").onChange((viewBoundingBox) => {
      frame.children.forEach((child) => {
        if (child instanceof THREE.Box3Helper) {
          child.visible = viewBoundingBox;
        }
      });
    });

    debugFolder.add(spark, "maxStdDev", 0.1, 3.0, 0.01).name("Max Gsplat stddev").listen();
    debugFolder.add(spark, "falloff", 0, 1, 0.01).name("Gaussian falloff").listen();
    debugFolder.add(spark, "preBlurAmount", 0, 2, 0.1).name("Blur amount (no AA)").listen();
    debugFolder.add(spark, "blurAmount", 0, 2, 0.1).name("Blur amount (AA)").listen();
    debugFolder.add({
      nonAA: () => {
        spark.preBlurAmount = 0.3;
        spark.blurAmount = 0.0;
      },
    }, "nonAA").name("Non-AA preset");
    debugFolder.add({
      AA: () => {
        spark.preBlurAmount = 0.0;
        spark.blurAmount = 0.3;
      },
    }, "AA").name("AA preset");
    debugFolder.add(spark, "focalAdjustment", 0.1, 2.0, 0.1).name("Tweak focalAdjustment");
    spark.defaultView.sort32 = true;
    debugFolder.add(spark.defaultView, "sort32").name("Float32 sort").listen();
    debugFolder.add(spark, "minPixelRadius", 0, 16, 0.1).name("Min pixel radius").listen();
    debugFolder.add(spark, "maxPixelRadius", 1, 1024, 1).name("Max pixel radius").listen();
    debugFolder.add(spark, "minAlpha", 0, 1, 0.001).name("Min alpha").listen();

    debugFolder.add(spark, "premultipliedAlpha").name("Premultiplied alpha").listen();
    debugFolder.add(spark.defaultView, "stochastic").name("Stochastic sort-free").listen().onChange(touch);

    const accumFolder = debugFolder.addFolder("Accumulator encoding").close();;
    accumFolder.add(spark.splatEncoding, "rgbMin", -1, 1, 0.1).name("RGB min").onChange(touch);
    accumFolder.add(spark.splatEncoding, "rgbMax", 0, 4, 0.1).name("RGB max").onChange(touch);
    accumFolder.add(spark.splatEncoding, "lnScaleMin", -14, -2.5, 0.1).name("Ln scale min").onChange(touch);
    accumFolder.add(spark.splatEncoding, "lnScaleMax", -14, 14, 0.1).name("Ln scale max").onChange(touch);

    const splatEncoding = {
      rgbMin: 0.0,
      rgbMax: 1.0,
      lnScaleMin: LN_SCALE_MIN,
      lnScaleMax: LN_SCALE_MAX,
      sh1Min: -1,
      sh1Max: 1,
      sh2Min: -1,
      sh2Max: 1,
      sh3Min: -1,
      sh3Max: 1,
    };
    const splatFolder = debugFolder.addFolder("SplatMesh encoding").close();
    splatFolder.add(splatEncoding, "rgbMin", -1, 1, 0.1).name("RGB min").onChange(touch);
    splatFolder.add(splatEncoding, "rgbMax", 0, 4, 0.1).name("RGB max").onChange(touch);
    splatFolder.add(splatEncoding, "lnScaleMin", -14, -2.5, 0.1).name("Ln scale min").onChange(touch);
    splatFolder.add(splatEncoding, "lnScaleMax", -14, 14, 0.1).name("Ln scale max").onChange(touch);
    splatFolder.add(splatEncoding, "sh1Min", -6, 6, 0.1).name("SH1 min").onChange(touch);
    splatFolder.add(splatEncoding, "sh1Max", -6, 6, 0.1).name("SH1 max").onChange(touch);
    splatFolder.add(splatEncoding, "sh2Min", -6, 6, 0.1).name("SH2 min").onChange(touch);
    splatFolder.add(splatEncoding, "sh2Max", -6, 6, 0.1).name("SH2 max").onChange(touch);
    splatFolder.add(splatEncoding, "sh3Min", -6, 6, 0.1).name("SH3 min").onChange(touch);
    splatFolder.add(splatEncoding, "sh3Max", -6, 6, 0.1).name("SH3 max").onChange(touch);

    const splatsFolder = secondGui.addFolder("Files");

    const clipFolder = gui.addFolder("Clip Splats").close();

    function updateFrameSplats() {
      frame.children.forEach((child) => {
        if (child instanceof SplatMesh) {
          child.updateVersion();
        }
      });
    }

    const clipEnable = dyno.dynoBool(false);
    const clipMinX = dyno.dynoFloat(-5);
    const clipMaxX = dyno.dynoFloat(5);
    const clipMinY = dyno.dynoFloat(-5);
    const clipMaxY = dyno.dynoFloat(5);
    const clipMinZ = dyno.dynoFloat(-5);
    const clipMaxZ = dyno.dynoFloat(5);
    clipFolder.add(clipEnable, "value").name("Enable clip").onChange(() => updateFrameSplats());
    clipFolder.add(clipMinX, "value", -50, 50, 0.01).name("Min X").onChange(() => updateFrameSplats());
    clipFolder.add(clipMaxX, "value", -50, 50, 0.01).name("Max X").onChange(() => updateFrameSplats());
    clipFolder.add(clipMinY, "value", -50, 50, 0.01).name("Min Y").onChange(() => updateFrameSplats());
    clipFolder.add(clipMaxY, "value", -50, 50, 0.01).name("Max Y").onChange(() => updateFrameSplats());
    clipFolder.add(clipMinZ, "value", -50, 50, 0.01).name("Min Z").onChange(() => updateFrameSplats());
    clipFolder.add(clipMaxZ, "value", -50, 50, 0.01).name("Max Z").onChange(() => updateFrameSplats());

    function makeWorldModifier(mesh) {
      const context = mesh.context;
      return dyno.dynoBlock({ gsplat: dyno.Gsplat }, { gsplat: dyno.Gsplat }, ({ gsplat }) => {
        // Color by world normal if it's enabled
        let worldNormal = dyno.gsplatNormal(gsplat);
        let { rgb, center, opacity } = dyno.splitGsplat(gsplat).outputs;
        // Compute vector from view to object coordinate in gsplat
        const worldToView = context.worldToView;
        const viewGsplat = worldToView.applyGsplat(gsplat);
        const viewCenter = dyno.splitGsplat(viewGsplat).outputs.center;
        let normal = dyno.gsplatNormal(viewGsplat);
        const dot = dyno.dot(viewCenter, normal);
        const sameDir = dyno.greaterThanEqual(dot, dyno.dynoConst("float", 0));
        normal = dyno.select(sameDir, dyno.neg(worldNormal), worldNormal);
        const normalRgb = dyno.add(dyno.mul(normal, dyno.dynoConst("float", 0.5)), dyno.dynoConst("float", 0.5));
        rgb = dyno.select(normalColor, normalRgb, rgb);

        // Zero out opacity if outside clip bounds
        const { x, y, z } = dyno.split(center).outputs;
        const xWithin = dyno.and(dyno.greaterThanEqual(x, clipMinX), dyno.lessThanEqual(x, clipMaxX));
        const yWithin = dyno.and(dyno.greaterThanEqual(y, clipMinY), dyno.lessThanEqual(y, clipMaxY));
        const zWithin = dyno.and(dyno.greaterThanEqual(z, clipMinZ), dyno.lessThanEqual(z, clipMaxZ));
        const within = dyno.and(dyno.and(xWithin, yWithin), zWithin);
        const splatEnabled = dyno.or(
          dyno.not(clipEnable),
          within,
        );
        opacity = dyno.select(splatEnabled, opacity, dyno.dynoConst("float", 0));

        gsplat = dyno.combineGsplat({
          gsplat,
          rgb,
          opacity,
        })
        return { gsplat };
      });
    }

    const exportFolder = secondGui.addFolder("Export Gsplats").close();
    const writeOptions = {
      filename: "gsplats",
      trimOpacity: true,
      trimOpacityThreshold: 0,
      maxSh: 3,
      fractionalBits: 12,
      writeSpz: async () => {
        const transcodeInputs = inputs.map((input) => {
          return {
            fileBytes: new Uint8Array(input.fileBytes),
            pathOrUrl: input.pathOrUrl,
            transform: {
              translate: input.object.position.toArray(),
              quaternion: input.object.quaternion.toArray(),
              scale: (input.object.scale.x + input.object.scale.y + input.object.scale.z) / 3,
            },
          };
        });

        const clipXyz = clipEnable.value ? {
          min: [clipMinX.value, clipMinY.value, clipMinZ.value],
          max: [clipMaxX.value, clipMaxY.value, clipMaxZ.value],
        } : undefined;
        const maxSh = writeOptions.maxSh;
        const fractionalBits = writeOptions.fractionalBits;
        const opacityThreshid = writeOptions.trimOpacity ? writeOptions.trimOpacityThreshold : undefined;
        const transcode = await transcodeSpz({ inputs: transcodeInputs, maxSh, clipXyz, fractionalBits, opacityThreshold: opacityThreshid });

        if (transcode.clippedCount && transcode.clippedCount > 0) {
          console.log(`Clipped ${transcode.clippedCount} splats. Consider decreasing fractional-bits from ${fractionalBits} to reduce clipping.`);
          alert(`Clipped ${transcode.clippedCount} splats. Consider decreasing fractional-bits from ${fractionalBits} to reduce clipping.`);
        }

        const blob = new Blob([transcode.fileBytes], { type: "application/octet-stream" });
        const url = URL.createObjectURL(blob);
        const a = document.createElement("a");
        a.href = url;
        a.download = writeOptions.filename + ".spz";
        a.click();
        URL.revokeObjectURL(url);
      },
    };
    exportFolder.add(writeOptions, "filename").name("Filename").listen();
    exportFolder.add(writeOptions, "trimOpacity").name("Trim low opacity");
    exportFolder.add(writeOptions, "trimOpacityThreshold").name("Trim opacity <= 0..1");
    exportFolder.add(writeOptions, "maxSh", 0, 3, 1).name("Max spherical harmonics");
    exportFolder.add(writeOptions, "fractionalBits", 6, 24, 1).name("Fractional bits");
    exportFolder.add(writeOptions, "writeSpz").name("Create .spz and download");

    function makeInstructions() {
      const instructions = textSplats({
        text: "Drag and Drop\na Gsplat file\nhere to view",
        textAlign: "center",
        fontSize: 64,
        objectScale: 0.1 / 64,
      });
      instructions.quaternion.set(1, 0, 0, 0);
      instructions.enableWorldToView = true;
      instructions.worldModifier = makeWorldModifier(instructions);
      instructions.updateGenerator();
      return instructions;
    }

    const instructions = makeInstructions();
    addBoundingBoxHelper(instructions);
    frame.add(instructions);

    // Load URLs from query parameters if any
    const urlsToLoad = parseURLsFromQuery();
    if (urlsToLoad.length > 0) {
      console.log(`Loading ${urlsToLoad.length} URLs from query parameters:`, urlsToLoad);
      loadFiles(urlsToLoad);
    }

    // Focus the canvas initially for keyboard controls
    canvas.focus();

    // Drag and drop functionality
    canvas.addEventListener("dragover", (e) => {
      e.preventDefault();
      canvas.style.opacity = "0.5";
    });

    canvas.addEventListener("dragleave", (e) => {
      e.preventDefault();
      canvas.style.opacity = "1";
    });

    canvas.addEventListener("drop", async (e) => {
      e.preventDefault();
      canvas.style.opacity = "1";

      // Check for text data first
      const textData = e.dataTransfer.getData('text/plain');
      if (textData) {
        const urls = parseURLsFromText(textData);
        if (urls.length > 0) {
          console.log(`Found ${urls.length} URLs in dropped text:`, urls);
          loadFiles(urls);
          return;
        }
      }

      // Fallback to file handling
      const files = Array.from(e.dataTransfer.files);
      console.log("Dropped files:", files.length, files);
      const splatFiles = files.filter((file) =>
        file.name.toLowerCase().endsWith('.ply') ||
        file.name.toLowerCase().endsWith('.spz') ||
        file.name.toLowerCase().endsWith('.splat') ||
        file.name.toLowerCase().endsWith('.ksplat') ||
        file.name.toLowerCase().endsWith('.zip') ||
        file.name.toLowerCase().endsWith('.sog')
      );

      if (splatFiles.length > 0) {
        loadFiles(splatFiles);
      }
    });

    // Parse URLs from text (handles single URLs, multiple lines, mixed content)
    function parseURLsFromText(text) {
      const supportedExtensions = [".ply", ".spz", ".splat", ".ksplat", ".zip", ".json"];
      const urls = [];

      // Split by lines, commas, and semicolons
      const parts = text.trim().split(/[\r\n,;]+/);

      for (const part of parts) {
        const trimmed = part.trim();

        // Check if part looks like a URL
        if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) {
          // Check if it has a supported extension
          const hasValidExtension = supportedExtensions.some(ext =>
            trimmed.toLowerCase().includes(ext)
          );

          if (hasValidExtension) {
            urls.push(trimmed);
          }
        }
      }

      return urls;
    }

    let lastTime = null;

    renderer.setAnimationLoop(function animate(time) {
      const deltaTime = time - (lastTime || time);
      lastTime = time;
      stats.begin();

      if (guiOptions.autoRotate) {
        frame.rotation.y += deltaTime / 5000;
      }

      if (guiOptions.orbit) {
        orbitControls.update();
      } else {
        controls.update(camera);
      }

      renderer.render(scene, camera);

      stats.end();
    });
  </script>
</body>

</html>
